File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
import { FoxCard } from '@fox/core/components/fox-card';
import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxInput } from '@fox/core/components/fox-input';
import { FoxPageHeader } from '@fox/core/components/fox-page-header';
import { FoxSelect } from '@fox/core/components/fox-select';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
import { useFeedback } from '@/app/_hooks/use-feedback';
import type { CommonCode } from '@/lib/domain/common-code';
import {
MERCHANT_DETAIL_VISIBILITY_OPTIONS,
MERCHANT_VOUCHER_USE_OPTIONS,
formatMerchantDetailStatusLabel,
formatOptionalValue,
type MerchantDetail,
type MerchantDetailSummary,
} from '@/lib/domain/merchant-detail';
import {
CLOSED_DAYS_MAX_LENGTH,
DESCRIPTION_MAX_LENGTH,
INITIAL_MERCHANT_DETAIL_FORM_STATE,
SORT_ORDER_MAX,
} from '@/lib/domain/merchant-detail-form';
import { MERCHANT_DETAILS_PATH, merchantDetailPath } from '@/lib/domain/merchant-detail-query';
import type { MerchantProduct } from '@/lib/domain/merchant-product';
import { formatPhoneNumber } from '@/lib/domain/phone-number';
import {
deleteMerchantDetailAction,
saveMerchantDetailAction,
updateMerchantDetailVisibilityAction,
} from '../../_actions';
import { MerchantChoiceChips } from '../../_components/merchant-choice-chips';
import { MerchantImageField } from '../../_components/merchant-image-field';
import { MerchantProductTable } from '../../_components/merchant-product-table';
import styles from './merchant-detail-form.module.scss';
interface MerchantDetailFormProps {
/** 상세가 있는 가맹점 — 고친다. */
detail?: MerchantDetail;
/** 상세가 아직 없는데 가맹점은 정해진 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
merchant?: MerchantDetailSummary;
/** 가맹점도 정해지지 않은 경우([+ 업체 등록]) — 업체명 자리에서 하나를 고른다. */
candidates?: MerchantDetailSummary[];
products?: MerchantProduct[];
categories: CommonCode[];
/** 메뉴 표의 배지 코드표(`FRCS_BADGE_CD`). */
badgeCodes?: CommonCode[];
}
/**
* 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면
* 고치고, `merchant`면 그 가맹점에 처음 만들고, 둘 다 없으면 업체명 자리에서 가맹점을 골라 만든다.
* 첫 저장 뒤에는 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후
* 저장은 그 자리에 머문다.
*
* 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹점 관리
* 화면의 것이라 여기서는 값을 바꾸지 않는다.
*/
export function MerchantDetailForm({
detail,
merchant: fixedMerchant,
candidates = [],
products = [],
categories,
badgeCodes = [],
}: MerchantDetailFormProps) {
const router = useRouter();
const { showAlert, hideAlert, showToast } = useFeedback();
const [isBusy, startAction] = useBlockingAction();
const [state, formAction, isPending] = useActionState(
saveMerchantDetailAction,
INITIAL_MERCHANT_DETAIL_FORM_STATE
);
const isEdit = detail !== undefined;
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
// 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다.
const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
const [frcsNo, setFrcsNo] = useState(pinned?.id ?? '');
const merchant: MerchantDetailSummary | null =
pinned ?? candidates.find((row) => row.id === frcsNo) ?? null;
const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
const [visibility, setVisibility] = useState<string[]>([
detail?.isVisible === false ? 'N' : 'Y',
]);
const [voucherUse, setVoucherUse] = useState<string[]>(['Y']);
useEffect(() => {
if (state.status !== 'success') return;
if (state.created) {
showToast({ variant: 'success', message: '등록했습니다. 이어서 메뉴를 추가할 수 있습니다.' });
router.replace(merchantDetailPath(state.frcsNo));
} else {
showToast({ variant: 'success', message: '저장했습니다.' });
router.refresh();
}
}, [state, router, showToast]);
function toggleVisibility() {
if (!detail) return;
const next = detail.isVisible === false;
startAction(async () => {
const result = await updateMerchantDetailVisibilityAction(detail.id, next);
showToast(
result.status === 'error'
? { variant: 'danger', message: result.message }
: { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' }
);
});
}
function runDelete() {
if (!detail) return;
hideAlert();
startAction(async () => {
const result = await deleteMerchantDetailAction(detail.id);
if (result.status === 'error') {
showToast({ variant: 'danger', message: result.message });
return;
}
showToast({ variant: 'success', message: '삭제했습니다.' });
router.push(MERCHANT_DETAILS_PATH);
});
}
function confirmDelete() {
if (!detail) return;
showAlert({
variant: 'danger',
title: '가맹몰 상세를 삭제하시겠습니까?',
message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
actions: (
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
<FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
</FoxButtonGroup>
),
});
}
return (
<>
<FoxPageHeader
title="가맹몰 상세 정보"
description="지역상생몰에 노출할 업체의 상세를 등록·수정한다. 저장 즉시 앱에 반영된다"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자관리' },
{ label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
{ label: '상세 정보' },
]}
/>
<form
id="merchant-detail-form"
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.form}
>
<input type="hidden" name="frcsNo" value={frcsNo} />
<input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} />
<FoxCard size="lg" title="기본 정보" className={styles.section}>
<div className={styles.grid}>
<div className={styles.fieldWide}>
{pinned ? (
<FoxInput size="md" label="업체명" requirement="required" value={pinned.name} readOnly state="view" />
) : (
<>
<FoxSelect
size="md"
label="업체명"
requirement="required"
placeholder="상세를 등록할 가맹점 선택"
options={candidates.map((row) => ({ value: row.id, label: row.name }))}
value={frcsNo}
onValueChange={setFrcsNo}
error={Boolean(errors.frcsNo)}
/>
{errors.frcsNo && <FoxHelperText type="danger" message={errors.frcsNo} />}
{candidates.length === 0 && (
<FoxHelperText
type="information"
message="상세를 붙일 가맹점이 없습니다. 모든 가맹점에 상세가 등록돼 있습니다."
/>
)}
</>
)}
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="카테고리"
requirement="required"
placeholder="선택"
options={categories.map((code) => ({ value: code.code, label: code.label }))}
value={categoryCode}
onValueChange={setCategoryCode}
error={Boolean(errors.categoryCode)}
/>
<input type="hidden" name="categoryCode" value={categoryCode} />
{errors.categoryCode && <FoxHelperText type="danger" message={errors.categoryCode} />}
</div>
<div className={styles.field}>
<FoxInput
size="md"
label="업체 연락처"
requirement="required"
value={formatOptionalValue(formatPhoneNumber(merchant?.phoneNumber ?? null))}
readOnly
state="view"
/>
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldNarrow}>
<FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(merchant?.postalCode)} readOnly state="view" />
</div>
<div className={styles.fieldWide}>
<FoxInput size="md" label="주소" value={formatOptionalValue(merchant?.address)} readOnly state="view" />
</div>
<div className={styles.field}>
<FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant?.addressDetail)} readOnly state="view" />
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput
size="md"
name="description"
label="한줄 소개"
placeholder="앱 목록·상세에 노출되는 한 줄 설명"
defaultValue={detail?.description ?? ''}
maxLength={DESCRIPTION_MAX_LENGTH}
invalid={Boolean(errors.description)}
message={errors.description}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="openTime"
type="time"
label="영업 시작"
defaultValue={detail?.openTime ?? ''}
invalid={Boolean(errors.openTime)}
message={errors.openTime}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="closeTime"
type="time"
label="영업 종료"
defaultValue={detail?.closeTime ?? ''}
invalid={Boolean(errors.closeTime)}
message={errors.closeTime}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="closedDays"
label="휴무일"
placeholder="예) 매주 월요일"
defaultValue={detail?.closedDays ?? ''}
maxLength={CLOSED_DAYS_MAX_LENGTH}
invalid={Boolean(errors.closedDays)}
message={errors.closedDays}
/>
</div>
</div>
<div className={styles.grid}>
<MerchantChoiceChips
label="노출 상태"
name="isVisible"
required
options={MERCHANT_DETAIL_VISIBILITY_OPTIONS}
values={visibility}
onChange={setVisibility}
/>
<MerchantChoiceChips
label="바우처 사용"
name="isVoucherUsable"
required
options={MERCHANT_VOUCHER_USE_OPTIONS}
values={voucherUse}
onChange={setVoucherUse}
/>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="sortOrder"
type="number"
inputMode="numeric"
min={0}
max={SORT_ORDER_MAX}
label="노출 순서"
placeholder="숫자 (낮을수록 상단)"
defaultValue={detail?.sortOrder == null ? '' : String(detail.sortOrder)}
invalid={Boolean(errors.sortOrder)}
message={errors.sortOrder}
/>
</div>
</div>
</FoxCard>
<FoxCard
size="lg"
title="이미지 등록"
description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)"
className={styles.section}
>
{/* 저장으로 파일 id가 바뀌면 다시 마운트해 칸 상태를 서버 값으로 되돌린다. */}
<MerchantImageField
key={`${detail?.thumbnailFileId ?? ''}/${detail?.imageFileId ?? ''}`}
defaultThumbnailUrl={detail?.thumbnailUrl ?? null}
retainedThumbnailId={detail?.thumbnailFileId ?? null}
defaultImageUrls={detail?.imageUrls ?? []}
retainedImageFileId={detail?.imageFileId ?? null}
thumbnailError={errors.thumbnail}
imagesError={errors.images}
/>
</FoxCard>
<FoxCard
size="lg"
title="메뉴 정보"
description="앱 상세 화면의 메뉴 목록에 노출된다"
className={styles.section}
>
<MerchantProductTable frcsNo={frcsNo} badgeCodes={badgeCodes} products={products} canAdd={isEdit} />
</FoxCard>
{detail && (
<FoxCard size="lg" title="등록 정보" className={styles.section}>
<FoxDescriptionList
layout="inline"
items={[
{ key: 'registeredAt', term: '등록일시', description: formatOptionalValue(detail.registeredAt) },
{ key: 'registeredBy', term: '등록자', description: formatOptionalValue(detail.registeredBy) },
{
key: 'modified',
term: '최종 수정',
description: [detail.modifiedAt, detail.modifiedBy]
.filter((value): value is string => Boolean(value))
.join(' / ') || '-',
},
{ key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) },
]}
/>
</FoxCard>
)}
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
<FoxButtonPanel>
<FoxButtonGroup size="lg">
{detail && (
<>
<FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
<FoxButton
type="default"
size="lg"
label={detail.isVisible === false ? '노출' : '비활성'}
disabled={isBusy}
onAction={toggleVisibility}
/>
</>
)}
<FoxButton
type="default"
size="lg"
label="취소"
onAction={() => router.push(MERCHANT_DETAILS_PATH)}
/>
<FoxButton
type="primary"
size="lg"
htmlType="submit"
label={isEdit ? '저장' : '등록'}
loading={isPending}
/>
</FoxButtonGroup>
</FoxButtonPanel>
</form>
</>
);
}